Repository navigation
Agents: steering, a message joins the turn being answered at its next step - #301
Conversation
|
@pullfrog review |
There was a problem hiding this comment.
ℹ️ No issues in the steering logic. One stale docstring reference, inline.
Reviewed changes
I reviewed the whole steering feature: the companion's inbox and placeholder protocol, how the conversation stores steered messages and reads them back, and each framework integration that takes them at a step.
- Companion protocol (
_signals.py):steeralways sends thesteeredplaceholder with the idempotency keysteer:<id>. It holds the message in a per-turn inbox capped atSTEERS, andtakenmarks it taken.publishreturnssteering._closed/resetforget the inbox. I traced the races (steer around a turn's close,takenarriving after the close, a turn retried with the same invocation id). Each message ends up answered exactly once. - Storage (
_conversation.py):Turn.steermirrors_insert. It stores each message once, using theUNIQUEid of the newrestate_agent_steerstable, and sendstakenfrom the same transaction's outbox. It is shielded, and checksgoing()again once it holds the ledger lock.AttemptEndedalso setsstop.stopped, so a store waiting on the lock when an attempt ends takes nothing. - Reading back (
_conversation.py,_messages.py): earlier turns carrysteers, which feedhistory(), the budget, summaries and the summary view. The page reads cover the right ranges, andlast_ofderivesseqfrom the tables, so the cursor moves forward for steered rows. latest_askskips steered messages, so the output re-ask, ADK's resumed invocation and graph runs work as before.- Framework integrations:
- OpenAI: the input filter splices the messages back into each later request, and the run also takes them as it starts.
- Pydantic AI: an innermost
_Steeringwrapped by_SnapshotscallsRunContext.enqueue(priority='asap'). I checked this signature and its semantics against the installedpydantic_ai2.66. - ADK:
_Restateis now added per run, andbefore_model_callbackadds the messages to the request and to the session. - LangGraph: the model node marks the messages with
steer:ids, and_latestnever starts a trimmed request at one.
- Other
dropcallers: the OpenAIdropcall sites (_resume, the output-guardrail re-ask, a blocked reply) only drop replies that cannot have steered messages after them. So the deletedrestate_agent_steersrows and the companion'stakenmarks never disagree in practice.
claude-opus-5-5 | 𝕏
There was a problem hiding this comment.
ℹ️ I found no defects in the core design. Two framework paths were checked less deeply than the rest (see below), and CI is still pending.
Reviewed changes
I reviewed all 36 files at 3104124: the core steering mechanism, the four framework integrations, the summary and transcript changes, the docs, and the tests and mutants.
steerview and privatesteeredhandler (_object.py): the view sends the message to the companion. The companion always queues asteeredplaceholder for it (idempotency keysteer:<id>), so the message keeps its place in the key's queue. The placeholder does nothing if a turn already stored the message.- Companion inbox (
_signals.py): the inbox holds messages only for a turn that started withsteers: true, at mostSTEERS = 16per turn, counting messages already taken.publishnow returns the messages still waiting,takenremoves their text, and_closedandresetclear the inbox. take_steersandTurn.steer(_steering.py,_conversation.py): these take messages only at a step with no open calls, while the turn is still going. The store is shielded and runs under the ledger lock. The DB uniqueness check and the step and steer rows commit together with the outboxtakensend. A new appended table,restate_agent_steers, has a uniqueid.- History and views:
latest_askskips steered messages.history(), the budget (_turn_size), summaries and the summary view now include each earlier turn's steered messages.dropdeletes steered messages after the dropped reply. - Frameworks:
- OpenAI: the input filter takes messages at each step, and
_Steers/_splicedput them back into every later request. - Pydantic AI:
_Steeringcallsenqueue('asap'). Feedback is now deduplicated perRetryFeedbackPart. - ADK: the
before_model_callbackplugin takes messages, for anLlmAgentroot only. - LangGraph: the built-in model node takes them, using
steer:<id>ids, and a trimmed request never starts at a steered message.
- OpenAI: the input filter takes messages at each step, and
ℹ️ What I checked myself, and what I checked less deeply
I traced the exactly-once path through each of these cases and found no case where a message is answered twice or never:
- the turn crashes after the store (
held, plus the unique DB read on retry); - the turn is stopped while waiting for the ledger (
going()is checked again under the lock); - the turn is killed (the next
startcloses it and the placeholder answers the message); - the turn closes before
takenarrives (takenis a no-op); - the view runs again (the companion call rejoins through its idempotency key);
- the cap is reached;
dropruns after a take (the message is either taken again under the sametaken:<id>key or answered by the placeholder).
final_reply() also returns None once a steered message comes last, so a retried turn asks the model again instead of returning a stale answer.
I checked these less deeply: whether _spliced places messages correctly in OpenAI requests after handoffs and user call_model_input_filters, and whether Pydantic AI's enqueue and ADK's plugin ordering behave as the code assumes in the installed versions. I confirmed that RunContext.enqueue exists in the installed Pydantic AI. Beyond that, these rely on the PR's own framework tests.
Local runs:
tests/agents_steering.py: 11 passed.- In-process tests matching
-k steeracross the framework test files: 13 passed. agents_conversation,agents_messagesandagents_snapshots: 57 passed.- The framework files, run in-process: no failures. Their Docker-harness tests could not run here.
ruffandpyrighton the core files: clean.
The CI Lint and Test and integration jobs were still pending when I reviewed.
claude-opus-5-5 | 𝕏
|
Follow-up to my review: I've now finished checking how OpenAI places steered messages in later requests ( A nested Taking messages is not affected: A possible fix: only splice into requests whose input contains this run's own anchor or first item, or whose The other open question from my review, how Pydantic AI, ADK and LangGraph behave in their installed versions, also checked out with no defects.
|
|
Thanks, the Fixed in 19a42c5. The run's hooks now count its running tool calls. The SDK makes the run's own next request only once all of its calls have ended, so a request made while one of them runs belongs to a nested run. Such a request gets the user's own filter only: nothing is stored, and no steered message is taken or placed. The fix comes with the test Generated by Claude Code |
There was a problem hiding this comment.
✅ No new issues found. The new commit fixes the
Agent.as_tooldefect raised in the follow-up comment.
Reviewed changes
I reviewed the one commit since 3104124, which keeps a nested OpenAI run (an agent used as a tool) separate from the turn.
_Hooks.runninggate:on_tool_startandon_tool_endcount the run's open tool calls. While any call is open,model_inputapplies only the user's owncall_model_input_filter(_filtered). A nested run therefore gets none of the turn's steered messages, and the turn stores none of the nested run's tool outputs. I checked this against the installedagentsSDK.as_toolpasses onrun_config(and our filter with it), but uses its ownhooksargument rather than the outer run's, so the nested run'son_llm_endandon_tool_*never reach_Hooks. A function tool'son_tool_endfollows everyon_tool_startexcept in two cases: a nested interruption, which fails the turn here (APPROVALS), and an error the tool raises, which fails the run. So the counter cannot be left non-zero for a later step._filteredextraction: this moved the user-filter call into a helper. The outer path is unchanged.- Test, mutant and docs: added
test_an_agent_used_as_a_tool_gets_none_of_the_turns_messages_and_stores_none_of_its_items, which passes locally, and theopenai-nested-run-filteredmutant. The module docstring andframeworks.mdnow describe the nested run's behaviour.
One behaviour change, which I consider acceptable: a nested run's request no longer sets requesting. If a nested model error propagates out of the tool, _go now classifies it as a tool's error (from_tool) rather than a provider refusal. That matches where the error happened from the turn's point of view.
claude-opus-5-5 | 𝕏
… step
A key answers one message at a time: a message sent while a turn runs
waits in Restate's queue and becomes the next turn, and to change course a
client stops the turn and sends again. This adds a second way in, which
leaves `chat` as it is: the agent's new `steer` view. The turn being
answered takes a steered message at its next step (once the model's tool
calls have their results, before its next request) and the model's next
request has it; a message that comes after the turn's last step is answered
next, as a turn of its own, in the order it was sent.
Delivery, in the companion (`signals`)
- `steer({id, text})` sends the agent's new private `steered` handler the
message at once (idempotency key `steer:<id>`): the message takes its
place in the key's queue as it is sent, so it is answered in order with
the chats sent after it, and even if the turn dies.
- If the turn being answered takes steered messages (`Started.steers`, a
field the turn's `start` adds, like `settles`), the companion also holds
the message in its `inbox`, up to 16 per turn (`STEERS`).
- `publish`, which a turn calls at each tool call's start and end, returns
that turn's inbox (`steering`, absent when empty). So a turn learns of a
message at no cost of its own: nobody steering costs nothing.
- `taken(ids)` forgets messages a turn stored; `_closed` forgets those a
closed turn left; `reset` (clear) forgets them all. `status` shows the
inbox (`steering`).
In the turn
- `take_steers(ctx, conversation)` (in `restate.agents.internal`), called
by an integration at a step, stores the messages the last `publish`
listed that are not stored, as messages of the user that are steps of the
turn, and returns them for the model's next request. It does nothing
outside a `chat` turn, for an agent whose framework does not say it takes
them (`chat(..., steers=True)`), for a background agent, while a tool call
has no result, or once the turn stops.
- `Turn.steer` stores them in one transaction, each once by its id (in the
new table `restate_agent_steers`, an appended migration), with the
companion's `taken` sent from the transaction's outbox: once, with the
commit. The store is shielded from a stop cancelling it mid-commit.
- `steered` answers the message as a turn of its own, unless the database
has it (a turn took it): so each message is answered once. A dropped
reply drops the messages steered after it, which `steered` then answers.
- `latest_ask` skips steered messages: the output check still asks again,
ADK still resumes the prompt's invocation, and a graph keeps its run.
- The model keeps seeing them: an earlier turn is its prompt, the messages
steered to it, and its answer, in `history`, the budget and the
summaries. The summary view shows them under their ids.
Also `AgentClient.steer` and the terminal chat's `/steer`.
Tests: the core in process (stored once, through a crash, after a drop,
with the output check, in the history and the summary view, the migration),
the companion on a server (the inbox, the placeholder, the cap, a closed
turn, clear), and an agent with a scripted framework on a server (a message
steered during a tool call reaches the next request; one after the last
step, or to an agent that takes none, or with no turn, is answered as a turn
of its own in order; a turn killed before its step leaves it to its own
turn). 569 passed; the 3 failures are the usual host.docker.internal ones.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uard it Docs (the reference pages) - turns.md: a Steering section (the steer view, the step, where a message waits, how the turn learns of it from publish, taken once, answered as a turn of its own otherwise, a taken message is its turn's, what the model and clients see, limits), the words "step" and "steered message", the status and summary views, the revisions, Started.steers alongside settles, and the guarantees: a turn is joined only by a steered message, at a step, and each is answered once. - conversation.md: restate_agent_steers (the first appended migration), a steered message as a step, earlier turns as prompt, steered messages and answer in history, budgets and summaries. - README.md: the bullet, the view, the module. - learnings.md: D6 to D10, why a view of its own, the placeholder queued as the message is sent, the inbox on publish, in place not a restart, and a late message as the next turn. - porting.md: the TypeScript step; access-patterns.md: the traffic. Traffic, measured (scripts/steering_traffic.py, against a real server, any checkout): a turn of one tool call that nobody steers costs what it costs before steering, 47 journal entries and 5 companion calls (start, three publishes, close), on this branch and on main alike. A turn that takes a steered message: 57 entries and 6 calls, the sixth the outbox's taken. The message itself: the steer view (5 entries), the companion's steer (13), and the placeholder that finds it taken (3). Mutants (scripts/resurrect.py, durability, from no fix): the placeholder answering a taken message, latest_ask counting steered messages, history dropping them, a message taken while a call is open, publish without the inbox, taken never sent, a drop keeping their rows, and the summary view hiding them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The in-process tier gains agents_steering, the server tier agents_steering_server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…step, in place
The built-in LangGraph agent (`langgraph.agent(model=...)`) now takes the
messages steered to the turn being answered (the `steer` view) at the
turn's next step, and says so: `chat(..., steers=True)`.
At the step
- The model node (`call_model`) calls `take_steers` before its request.
The graph reaches that node after the tools node, which stores every tool
call's result and then publishes (`tool_call_ended`): the publish's
response is how the turn learns of the waiting messages. `take_steers`
stores each once, as a message of the user that is a step of the turn,
and returns the new ones; it returns nothing, at no cost, when nothing
waits, a call has no result, the turn stops, or it is no `chat` turn (a
handler of your own that calls `langgraph.prompt`). At the run's start
the node calls it too: a message the turn already knows of then goes into
the first request.
- The request gets them after what the state holds, so after the tool
results, and the node returns them with the reply (`{"messages":
[*steered, reply]}`), in the order the conversation stored them: the
graph's state keeps them for the rounds after.
Why in place
- Running the graph again with the message would reset LangGraph's
recursion limit, which is what ends a turn whose model keeps calling
tools. Returned in the model node's update, the messages are appended by
the add_messages reducer and the run goes on, within its limit.
Marked as steered
- In the graph, a steered message has the id `steer:<its id>`, which no
other message has. The conversation stores it without one, so `_history`
marks the stored ones that the history holds (a retried attempt's own, and
earlier turns'), by the entries whose items `history()` returns
(`Turn.steered()`, and the transcript's `steers`).
- `_latest` trims each request to the model's window with LangChain's
`trim_messages(start_on="human")`, and sends the latest prompt whole when
nothing fits. A steered message is a `HumanMessage`, so either could have
started the request at it, without the turn's prompt and the tool calls
and results before it. Now a request starts at the first prompt among the
messages that fit, never at a steered message, else at the latest prompt,
whole. Without steered messages, requests are as before.
`graph=` agents take none
- A graph of your own takes no steered messages in this version: its steps
are its own, and `_graph_agent` does not pass `steers=True`. So `steer`
answers that no turn takes the message, and it is answered as a turn of
its own, after the one being answered, in the order it was sent.
Docs: the steering point in `_agent.py`'s docstring and `agent()`'s, the
`graph=` notes in `_graph.py` and the package's, and LangGraph's section of
docs/agents/frameworks.md.
Tests, in tests/agents_langgraph.py, on a server: an agent whose scripted
model records each request and answers the latest message of the user, with
a window that holds no message, so that each request is the latest prompt
and what followed it (a steered message taken for a prompt would show).
- A message steered while a tool waits on a gate is in the very next
request, once, after the tool's result, under `steer:<id>`. The turn's
answer is the reply to it; it is stored once, and the summary view shows
the prompt, the message and the answer.
- A message steered after the first tool round, which has the model call a
tool again, is still in the request after the second round, in place.
- A crash after the message is stored (the model's request with it fails
the attempt): the retried attempt's request is the crashed one, with the
message once and marked, and it is not stored again.
- `_latest` (in process): a request starts at the turn's prompt, never at a
steered message, when only that fits and when nothing does, nor at one of
an earlier turn.
- tests/agents_langgraph_graph.py: a `graph=` agent's `steer` answers that
no turn takes the message, which is answered next, as a turn of its own.
Each of these mutations fails them: no state update, no marks on the
history, no steered messages in the request, no `steers=True`, `_latest`
ignoring the marks. tests/agents_langgraph*.py, agents_steering.py and
agents_steering_server.py: 110 passed. All agents tests: 393 passed, and
the usual host.docker.internal failure of examples_agents.py.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uest An ADK agent whose root is an LlmAgent (the built-in agent, or an agent or App of your own with one) now says it takes steered messages (`chat(..., steers=True)`): a message sent to `steer` while a turn runs joins the turn at its next step, and the model's next request has it. The run goes on in place; it is not restarted. At the step - `_Restate`, this package's plugin, takes the messages in its `before_model_callback`, as a model request goes out that ADK built from the session's events. `take_steers` stores those the last `publish` listed, only once each of the model's tool calls has a result, and each once. Each is appended to that request's contents and, through `_Sessions.steered`, to the in-memory session after its events (not stored again), so that every later request of the run has it, in place. - The plugin is now built per run, with that run's `_Sessions` (it was built with the app, which no longer takes `ctx`): it reaches the conversation without a private accessor of ADK's. It still comes after the app's own plugins. Why at the request, rather than as the tools' results are stored - Taking them in `_Sessions.append_event`, after a function response, was the first plan. ADK makes no request after some function responses: a tool's `skip_summarization` (its result is the answer), an output schema answered through `set_model_response` (ADK makes the final event itself), and, from ADK's code, an auth or confirmation request that ends the invocation. In process, a message steered while such a tool ran was stored by the turn and never shown to the model. At the request, a step that ADK ends takes nothing, and the core answers the message next, as a turn of its own. An invocation of its own - A steered message is an event of the user that starts an invocation of its own (`_Items.prompt`, which the core makes it with), never the running one. After a crash, ADK resumes the prompt's invocation from that invocation's events (`latest_ask` skips steered messages, so `_final` and `_run` keep resuming the prompt's, or the output re-ask's). ADK's classic runner, which runs a root that is no LlmAgent and resumes its LlmAgents from their agent states, takes a message of the user last among those events for the answer to a tool call, and raises `ValueError: No agent to transfer to for resuming agent from function response step` on every retry: with the running invocation's id, a crash after the store fails the turn for good. On ADK 2.14 an LlmAgent root runs on the node runner, which loads no agent states, so a same-id message would resume there; a test that forces a SequentialAgent root to take steered messages shows the failure. - With the default `include_contents`, ADK puts a user event of another invocation in the request (`_should_include_event_in_context` does not filter by invocation): it is in every request after it, in its place. Only LlmAgent roots, for now - `_steers(own)`: the built-in agent, or an agent or App whose root is an LlmAgent. A workflow root (a SequentialAgent, ParallelAgent or LoopAgent, an App's Workflow) passes `steers=False`: each message steered to it is answered as a turn of its own. Limits, documented in the module's docstring - A message sent during the model's last reply, or while a tool runs after whose result ADK asks the model nothing more, is answered next, as a turn of its own. - An agent with `include_contents='none'` starts each request at the latest message of the user: the requests after the one that takes a steered message start at it, without the prompt and the tools' results before it. - An AgentTool is one call: ADK gives its runner our plugin, but the parent's call is open while its agent runs, so its requests take nothing; the step is after its result. - An agent's own before_model_callback runs after the plugins: one that answers in the model's place gets the request with the message in it. Tests, in tests/agents_adk.py (against a Restate server unless in process) - A message steered while a tool runs reaches the next request, after the function response, once; the model answers it with a call of its own and the request after that still has it in place; it is stored once (its own queued turn does nothing), and the summary view shows prompt, steered message, answer. - A crash on the request with the message: the retry resumes the prompt's invocation, its request has the message once, the tool ran once, the model was not asked for the call again, and nothing is stored twice. - A SequentialAgent root takes no steered message (`turn` is None), which is answered as the next turn. - A message steered during the last model call is answered as the next turn. - In process: after a tool's skip_summarization, and after an output schema's set_model_response, no request follows and the turn stores nothing. - In process: on ADK's classic runner (a SequentialAgent root made to take steered messages), the retry after a crash resumes past the message. Each fails without what it covers (taking at the function response, steers for no root or for every root, no append to the request, no append to the session, the running invocation's id). tests/agents_adk.py 34 passed (27 before), tests/agents_steering.py 9, tests/agents_steering_server.py 5; ruff, pyright (0 errors) and mypy clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Pydantic AI agent now says it takes steered messages (`steers=True` in `agent()`'s `chat`), and every run takes them at each step, with a capability of its own (`_Steering`). At the boundary - Before each model request, once the model's tool calls all have their results stored (by `_Restate`, earlier in the same before-chain), it calls `take_steers`: the messages that wait are stored once, as messages of the user that are steps of the turn (`ModelRequest(parts=[UserPromptPart])`), and returned. - It then enqueues those same stored requests with Pydantic AI's own `RunContext.enqueue(..., priority="asap")`. Content enqueued from a `before_model_request` hook is delivered once the whole before-chain ran (`_agent_graph._apply_before_model_request`), into the request being prepared and into the run's history (`ctx.messages`), after what both hold: right after the tools' results. So what the model gets is what is stored, where it is stored, and a retried attempt or the next turn rebuilds the same history from the database. - In place: the run carries on with the message. Starting `agent.run` again from the stored history would reset the run's usage limits, its retries and its capabilities' state. - A whole `ModelRequest` enqueued outside a tool call is never spliced into the request that carries the tools' results (Pydantic AI splices only parts enqueued during a tool call, and `ctx.tool_call_id` is None in `before_model_request`): it is a request of its own, which `_Restate` never stores again (it holds no result and no feedback). - It takes nothing for a resumed suspended response: that request ends with the paused `ModelResponse`, into which Pydantic AI delivers nothing, so a message taken then would reach the model after the response though stored before it. The next step takes it. - Outside a `chat` turn that takes steered messages (a `prompt` in a handler of one's own, a background agent), `take_steers` returns nothing, at no cost. Snapshots - `_Steering` is innermost, ordered after `_Snapshots` (`CapabilityOrdering(position="innermost", wrapped_by=[_Snapshots])`). A snapshot covers every position stored so far (`ledger.next - 1`) and holds the request's messages as the before-chain left them, before the delivery: a message stored before a snapshot of the same request would be covered by it but not in it, and lost from the next turn's history and from a retried attempt. Stored after it, it comes after the snapshot's items. - Without snapshots (a `chat` of one's own that answers with `prompt`), the ordering has nothing to follow, and the turn projection has the messages in their turn. Feedback found by its parts (`_Restate._new`) - A run merges the consecutive requests it is given, and resumes from the last one as a new `ModelRequest` with the same parts. `_Restate` found Pydantic AI's feedback (`RetryFeedbackPart`) by the whole request's JSON, so a merged or resumed feedback request was taken as new and stored again, then again in every later turn. That was so before steering: a retry after a stored feedback stored it twice. Steering makes consecutive requests common (a result, or a feedback, then the steered message): after a retry that took a message right after a stored feedback, the next turn stored the feedback and the message again, and the views showed the message twice. Each `RetryFeedbackPart` is now found by its own JSON (it has its own time), which merges and resumes keep. Limitations - A turn learns of a message only from the companion's `publish`, which a tool call's start and end make: a step that ran no tool (Pydantic AI's feedback on an answer that does not validate, say) takes only what the last publish listed. A message that comes during the last model request is answered next, as a turn of its own. - Not changed here: what other capabilities enqueue as requests of their own (the harness's Planning, SystemReminders, DetectLoops, WarnNearLimits, Memory, ...) reaches the model and the run's history, but `_Restate` stores only results and feedback, so a retried attempt and the next turn do not have it. Tests - On a server, with a scripted model and a tool held on a `support.Gate`: a message steered during the tool call reaches the very next request, after the tool's result, once; the turn's answer is the reply to it; it is stored once, the summary view shows the prompt, the message and the answer, and its queued turn of its own does nothing. A crash while the model answers it: the retried request has it once, and it is not stored again. A message steered during the last model request is answered as the next turn. - In process: a compaction (of one's own, at the step) and a steered message in the same request: the snapshot does not hold it, and a retried attempt and the next turn get it after the snapshot's items, once (this fails with `_Steering` ordered before `_Snapshots`). A `chat` of one's own that answers with `prompt`, without snapshots, takes two messages, in order. A feedback, a crash, a retry that takes a message right after the feedback, and the next turn: each stored once, and given to the model once (this fails with the former `_new`). - tests/agents_pydantic_ai.py, agents_pydantic_ai_snapshots.py, agents_pydantic_ai_stores.py, agents_steering.py and agents_steering_server.py: 84 passed (78 before). ruff, pyright and mypy are clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An OpenAI agent's turn now takes the messages steered to it (the agent's steer view) at its next step, and its run goes on with them: agent() says so to chat (steers=True), and the run's call_model_input_filter, which already stores the tools' outputs right before each model request, takes them there. At a step - The input filter (_Hooks.model_input) stores the new tool outputs, then calls take_steers: once each of the model's tool calls has its output, the turn stores the waiting messages, once each, as messages of the user, and the request about to go out has them right after those outputs. - A run also takes the messages waiting as it starts (_go, after _resume closed a cut-off reply): they are stored before Runner.run, so they are in its input. A request whose last item cannot be told apart in later requests (a message of the user, as a run's first request ends with) takes none: its messages wait for the next step. In place, not a restart - A new Runner.run from the stored history would run the input guardrails again, start max_turns over, and reset the tool-choice tracking, the agent-start hooks and the trace. The filter changes only the request about to go out, and the run goes on as it was. The splice - The SDK makes each request's input afresh (the run's input plus its generated items, copied for the filter), so a message put in one request is gone from the next. The run keeps the messages it took, each with the key of the item it came after (a tool call's or output's call_id, else the item's own id: _key), and puts each back in every later request right after that item. Messages taken together keep their order; an item found twice counts where its last copy is, the one the SDK's dedup keeps. User messages have no id, so that dedup never drops them. - If a handoff's input_filter took that item out, the message goes right before the first item that came after it (one whose key the run first saw after taking it), as if the filter had kept the message; else at the end. - The run's own call_model_input_filter (a ToolOutputTrimmer, say) still runs after, on the input with the messages. On a retry - Stored messages come back in conversation.history() at their places, after the outputs they followed, so a resumed run's input has each once; take_steers never returns one twice, and the new run splices only what it took itself. A stored message after the outputs leaves _resume no open call, and makes run() ask the model rather than take a final reply. Limitations - A message is taken only at a step: one that comes while the model writes its final answer is answered next, as a turn of its own (the agent's steered handler); one sent during a long tool call waits for it. - A handoff whose input_filter takes its own output out of the next agent's input (handoff_filters.remove_all_tools does) leaves the handoff call without an output in the turn, so take_steers takes nothing for the rest of that run (by design: the turn has an open call). - The fallback placement counts an item with no key (a Chat Completions message, whose id is the SDK's fake one) as having come before the message. - The SDK's ToolOutputTrimmer counts user messages as turns, so a steered message makes the outputs before it older to it, as on a resumed run. Found, not changed (independent of steering): a handoff whose input_filter removes the handoff's own output never has that output stored, so every later request of the conversation carries the transfer call without an output, which OpenAI's APIs reject. The same on the base commit. Tests (tests/agents_openai.py) - Against a server: a message steered while a tool waits on a gate is in the next request, once, right after the tool's output; the turn's answer is the reply to it; the summary view shows the prompt, the message and the answer; its queued turn of its own finds it taken. A message steered while the model writes its final answer is the next turn. - In process, with a companion whose publish lists the messages: a message taken after the first of two tool rounds is in the request after the second, in its place, once, stored once and taken once; after a crash once the filter stored it, the retry's request has it once and it is not stored again; an input guardrail runs once; a message waiting as the run starts is in its first request; after a remove_all_tools handoff the message keeps its place and the run takes no more; the splice itself (order, a repeated item, the fallback). - A mutation of each fails a test: no steers=True, the current request only, no take as the run starts, always at the end, the fallback at the end, the first copy of an item, a restart instead of in place (the guardrail test). tests/agents_openai.py 54 passed (46 + 8), agents_steering.py 9 and agents_steering_server.py 5; the whole suite 577 passed, with the 3 usual host.docker.internal failures. ruff, pyright and mypy are clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frameworks.md gains a row for steered messages and a note per framework: OpenAI splices them back into each later request, Pydantic AI enqueues them after any snapshot, ADK adds them as a request goes out, for an LlmAgent root. It also fixes the note on which capability is innermost, and records two gaps found on the way: an OpenAI handoff filter that drops the handoff's output, and what other Pydantic AI capabilities enqueue, which is not stored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e fixes of a self review - The cap of 16 counted only the messages a turn had not taken yet, so a turn steered on and on took them without end. `taken` now marks a message (without its text) rather than forgetting it, until the turn ends: the cap counts them all, and a message sent again is still the turn's. - `taken` publishes only a change: one for a turn already closed wakes no follower. - A turn that stopped while it waited for the ledger to store a message takes none, which is then answered as a turn of its own. A message the turn did take shares its fate (stopped, refused, failed), as documented. - The turn just answered counts its steered messages toward the budget in the same attempt (`prompt.steers`), so a summary due is asked for now rather than a turn later; a drop trims them. - `publish` reads the companion's turn only when a message waits, and `status` reads it once; `_steps` reads the steered marks with one join; `Turn.steer` uses `_INSERT_STEP`. - ADK: the agent's own before_model_callback runs after the plugin's, on the request with the message, so a response it gives answers it too. Three more mutants in scripts/resurrect.py guard the cap, the stop and the budget; the publish mutant follows the code it mutates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A turn is now its prompt, the messages steered to it that it took, and what answers them: turns.md says so where it defines a turn, its life (a step takes the messages its publish lists), how it ends (the messages it took go as it goes, those it did not are answered after it), its attempts, stopping and clear, the turn model and the guarantees. "One message at a time" is now "One turn at a time". Steering a turn opens with what happens to a steered message, then three cases as sequence diagrams: taken at the next step, too late for the turn, and a turn that stops. README and conversation.md follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s items The conversation now makes and stores a steered message as the framework's own item, so no integration works out which messages were steered: - `Items.steered(text, steer_id)`, optional like `summary`, makes the item of a steered message (by default a prompt's). LangGraph's carries the id `steer:<id>` and is stored with it, so its history, a retried attempt's and the next turns', tells steered messages from prompts with no pass that marks them again (`_history` and `_as_steered` are gone). ADK's says why it starts an invocation of its own. - `take_steers` returns the items the request gets, not entries: no integration reads an `Entry` for them any more. - `Turn.steer` skips the messages the turn already holds, and takes `taken` and `going` as required callbacks. Its stored-id query is shared with `steer_stored`. Readability: - ADK's plugin takes the messages at its step. `_Sessions` only adds the stored events to the session (`add_stored`), and no longer needs `ctx`. - OpenAI's in-run state for the messages a run took, which each later request gets again, is one small class (`_Steers`), out of the run hooks. - The turn's copy of its waiting messages is `Inbox` (it was `Steering`, a name `_api` also gives another type), and the companion's `steer` takes `_api.Steer`, which `SteerRequest` duplicated. - One `_turn_size` for the budget and the transcript's walk, and one helper for the summary view's messages of the user. Mutants: the steering ones follow the code, and `steer-langgraph-unmarked` is new. `langgraph-reask-stored-reply` matched no code since the steering change, and now does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…st a server agents_object.py has no steering tests: the companion's are in agents_signals.py, and the agent's steered placeholder's in agents_steering_server.py (Pullfrog's review). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`Agent.as_tool` runs its agent with the outer run's `run_config` when given none, so its requests went through the turn's input filter: they got the messages steered to the turn, and the turn stored the nested run's tool outputs, without their calls (which breaks the next turn's request). A request made while one of the run's tool calls runs is a nested run's (the SDK makes the run's own next request once all of its calls end): it gets the user's own filter only. Test and mutant `openai-nested-run-filtered`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
19a42c5 to
fe79371
Compare

Summary
Steering lets a person add to a request while the agent is still working on it ("use Celsius", "skip Friday"). A message sent to the new
steerendpoint joins the turn being answered at its next step, so the model reads it in its very next request. If the turn has no step left, the message is answered as the next turn instead. Either way it is answered exactly once, through crashes, retries, stops and kills.chatis unchanged, and a turn that nobody steers makes no extra calls.Turn semantics, before and after
chat: the same. Sent tosteer: the running turn takes it at its next step, or else it is the next turnA step is the point where the model's reply has a result for each of its tool calls, just before the model's next request. It is the only place a turn takes steered messages, so the model never gets one between a tool call and its result.
What happens to a steered message
The endpoint answers
{id, turn}: the message's id, and the turn that holds it, ornullwhen it will be a turn of its own. Clients should send it with an idempotency key, so a retried request is the same message.How it works
steerview passes the message to the agent's companion (signals).steeredinvocation for it on the key (idempotency keysteer:<id>), so the message keeps its place in line. If the running turn takes steered messages, the companion also holds it in an inbox for that turn.publish, which it already calls at each tool call's start and end: the response now lists the messages waiting. At its next step, the framework integration callstake_steers. That stores each message once, by its id, as a user message of the turn, made by the framework's ownItems(steered, by default a prompt's item), and returns those items, which the integration puts into the request about to go out. The same transaction sends the companiontaken, through its outbox.steeredruns once the turn has ended. If a turn stored the message, it does nothing; otherwise it answers it as an ordinary turn.Exactly once comes from two unique keys: one
steeredinvocation per message id (Restate's idempotency), and at most one stored copy per id (a unique column of the newrestate_agent_steerstable), whichsteeredchecks.The conversation owns which messages were steered: it stores them as the framework marks them, so
history(), a retried attempt and the next turns hand them back as they were, and no integration looks them up again.Taken at the next step
sequenceDiagram autonumber participant C as Client participant V as Agent (steer view) participant S as Companion participant A as Agent (chat) participant P as Agent (steered) participant DB as Key's database C->>A: chat/send("Plan my week") A->>S: start(turn, steers) A->>DB: the model's reply, a tool call C->>V: steer("Skip Friday") V->>S: steer(id, text) S-)P: steered(id, text): queued now, behind the turn Note over S: inbox: the message, for this turn A->>S: publish (the tool call ends) S-->>A: revision, steering: [the message] A->>DB: the tool's result A->>DB: the message, a step of the turn, and taken(id) in the transaction's outbox DB--)S: taken(id), once the transaction commits A->>A: the model's next request has it A->>DB: the answer A->>S: close(turn) Note over P: runs once the turn ended: the message is stored, so it does nothingToo late: the next turn
The model is already writing its final answer, so no step follows. The turn ends without the message, and the message's own turn answers it next, before a chat sent after it.
sequenceDiagram autonumber participant C as Client participant S as Companion participant A as Agent (chat) participant P as Agent (steered) participant DB as Key's database C->>A: chat/send("Plan my week") Note over A: the tools are done: the model writes the final answer C->>S: steer("Skip Friday"), through the steer view S-)P: steered(id, text): queued behind the turn Note over S: inbox: the message, for this turn C->>A: chat/send("And next week?"): queued behind steered A->>DB: the answer A->>S: close(turn): the message it did not take is forgotten here P->>DB: stored by a turn? no P->>DB: a turn of its own: "Skip Friday", and its answer Note over A: then "And next week?" is answeredThe turn is stopped
A message the turn took is its own, like its prompt, so it is stopped with it. A message the turn had not taken yet is answered as a turn of its own afterwards. A turn that is stopped while it waits to store a message takes none.
sequenceDiagram autonumber participant C as Client participant S as Companion participant A as Agent (chat) participant P as Agent (steered) participant DB as Key's database C->>S: steer("Use Celsius"), through the steer view S-)P: steered(1): queued behind the turn A->>DB: at a step: "Use Celsius", a step of the turn, and taken(1) C->>S: steer("And the rain") S-)P: steered(2): queued behind steered(1) C->>A: stop A->>DB: the turn ends cancelled, and "Use Celsius" with it A->>S: close(turn): message 2, not taken, is forgotten here P->>DB: steered(1): stored by a turn? yes: nothing to do P->>DB: steered(2): stored? no: a turn of its own, "And the rain", and its answerA crash after the message is stored
The message and
takencommit together, so a retried attempt finds the message in the turn, gives it to the model in place, and never takes it again.What the model and clients see
history(), the budget and summaries.latest_askskips steered messages, so the output re-ask, ADK's resumed invocation and a graph's run behave as before.messagesviews show steered messages under their own ids, andstatus.steeringlists those still waiting.AgentClient.steerand/steerin the terminal chat send one.Frameworks
Each integration takes the messages only as a model request goes out, and puts the items
take_steersreturns into that request:_Steers) splices them back into every later request, after the item each came after. A run also takes the messages waiting as it starts. Input guardrails don't run again. An agent used as a tool (Agent.as_tool) runs with the run'srun_config, our filter with it: its requests get none of the messages._Steeringcapability, wrapped by_Snapshots, hands them to Pydantic AI's ownRunContext.enqueue(priority='asap'). A snapshot stored by the same request covers only what came before them._Restate.before_model_callback, for anLlmAgentroot, adds them to the request and to the session. Each has an invocation id of its own (_Items.steered), so a retry still resumes the prompt's invocation. The agent's ownbefore_model_callbackruns afterwards on the request with them, so a response it gives in place of the model's answers them too._Items.steeredgives each the idsteer:<id>, which is stored with it, and the model node keeps them in the run's state. A trimmed request never starts at one.Limits
STEERS); the companion counts the ones it took until the turn ends. Past that, each is a turn of its own.skip_summarization,set_model_response).graph=agent;input_filterdrops the handoff's own output (see below).Traffic
Measured on a real server with
scripts/steering_traffic.py, for a turn of one tool call:mainand on this branchstart, 3 ×publish,close)taken, from the outbox)steerview: 5, companionsteer: 13, placeholder that finds it taken: 3When nobody steers, a turn makes no extra calls. Inside the companion,
publishdoes one more state read, of the empty inbox.Compatibility
Started.steers,publish'ssteering,Status.steering). A TypeScript companion has nosteerhandler, so thesteerview fails against it, and turns take nothing from it. The port is step 8 ofdocs/agents/porting.md.steerandsteeredare now built-in handler names. A user handler with either name is refused.Testing
45 new tests, and 13 durability mutants in
scripts/resurrect.py(all caught).tests/agents_steering.py, 11 tests): stored once, after a crash too; nothing taken while a call is open, outside achatturn, or once the turn stopped; earlier turns as prompt, steered messages, answer; the budget counts them at once; the output check still asks again once; a drop frees the message; the summary view and cursors; the migration.tests/agents_signals.py, 3 new): the inbox and the placeholder;publishandtaken; a closed turn forgets its messages;clear; the cap, which counts messages already taken.tests/agents_steering_server.py, 5 tests): taken at the next step; too late, answered next in order; no turn running; an agent that takes none; a killed turn.promptwithout snapshots.graph=agent takes none.mainmerged in: 607 passed. ruff, pyright (strict) and mypy are clean.Found on the way
RetryFeedbackPartrequest again whenever a run merged or resumed it, on a retry and then on every later turn._Restatenow recognizes feedback by its parts.Agent.as_tool) runs with the outer run'srun_config, so the turn's input filter stored its tool outputs, without their calls, which broke the next turn's request. A request made while one of the run's tool calls runs is now the nested run's, and gets the user's own filter only.frameworks.md:input_filterdrops the handoff's own output (remove_all_tools) never stores that output. The next turns' requests then carry a function call with no output, which OpenAI's APIs refuse. Steering takes nothing after such a handoff.enqueue(the harness's system reminders, loop detection, and so on) reaches the model but is never stored, so a retry and the next turns lack it.Docs
turns.mdis the reference: a turn is now defined as its prompt, the messages steered to it, and their answer, through its life, its end, retries and stops, and Steering a turn has the cases above. Alsoframeworks.md(a row,Items.steered, and notes per framework),conversation.md,README.md,learnings.md(D6 to D10),porting.md,access-patterns.mdandtesting.md.🤖 Generated with Claude Code